Skip to content

Mark local skill builds with an OCI descriptor annotation#5000

Merged
samuv merged 3 commits intomainfrom
skill-local-build-2
Apr 22, 2026
Merged

Mark local skill builds with an OCI descriptor annotation#5000
samuv merged 3 commits intomainfrom
skill-local-build-2

Conversation

@samuv
Copy link
Copy Markdown
Contributor

@samuv samuv commented Apr 22, 2026

Summary

  • Supersedes Hide pulled skill artifacts from local builds listing #4971. Same user-facing behaviour, different mechanism — following review feedback on the sidecar approach (discussion).
  • GET /api/v1beta/skills/content and OCI-based install both tag the shared local OCI store as a cache — then ListBuilds (GET /api/v1beta/skills/builds) iterates every tag and reports them as local builds, so every previewed or installed remote skill pollutes the "what have I built locally?" view.
  • Encode the "I built this locally" signal directly on the OCI artifact: a dev.stacklok.toolhive.local-build=true annotation stamped on the root-index descriptor entry in index.json when Build tags, and read back by ListBuilds as the filter.
  • Pulled tags (install + content-preview) do not get the annotation — not by an explicit strip step, but because Registry.Pull tags by digest, and oras-go's oci.Store.Resolve returns a plain descriptor for digest references (annotations stripped). So pulled blobs stay in the OCI store as a cache but their tag is invisible to ListBuilds.
  • Push is also safe: push resolves by digest too, so the local-build marker never crosses the wire and the pushed artifact digest matches the local one.

Why not a builds.json sidecar (the #4971 approach)?

Single source of truth, no sidecar to drift, no stale-entry pruning on list, no atomic-write path to maintain. Most importantly: no migration heuristic — #4971 had a looksLikePulledRef grandfather rule that could silently hide a pre-existing my-skill:v1.0.0 tag, which is a real failure mode. With annotations, pre-feature builds simply don't show up until rebuilt. Honest gap beats silent misclassification. As a bonus, the same marker composes with locally-built container images / MCP server images in the future, not just skills.

Type of change

  • Bug fix (user-facing behaviour of ListBuilds / GET /api/v1beta/skills/builds)

Test plan

  • Unit tests (go test ./pkg/skills/skillsvc/... -count=1)
  • Full build (go build ./...)
  • TestBuild_StampsLocalBuildAnnotation inspects index.json directly to confirm the annotation lands on the descriptor entry after Build
  • TestDeleteBuild/delete_removes_local-build_marker_from_index.json confirms the annotation disappears with the tag
  • TestGetContent/remote_pull_does_not_pollute_ListBuilds and TestInstallFromOCI_DoesNotLeakIntoListBuilds simulate the real Pull tagging side-effect and assert ListBuilds stays empty

Changes

File Change
pkg/skills/skillsvc/local_build_marker.go New: LocalBuildAnnotation constant + tagAsLocalBuild / isLocalBuild helpers. Uses store.Target().Resolve / .Tag to round-trip the descriptor annotation through index.json.
pkg/skills/skillsvc/build.go Build calls tagAsLocalBuild instead of ociStore.Tag. ListBuilds iterates ListTags and filters by isLocalBuild before the usual metadata enrichment. DeleteBuild is unchanged; the annotation disappears with the descriptor entry.
pkg/skills/skillsvc/content.go Intent-documenting comment at the registry.Pull site: pulled tags are invisible to ListBuilds because Pull tags by digest, which yields a plain descriptor.
pkg/skills/skillsvc/install_oci.go Same intent-documenting comment at the install pull site.
pkg/skills/skillsvc/build_test.go Switch existing Build/ListBuilds/DeleteBuild tests to use tagAsLocalBuild where they previously called ociStore.Tag. New TestBuild_StampsLocalBuildAnnotation (reads index.json) and updated TestDeleteBuild case. New TestListBuilds cases for pulled-tag hiding and the pull + build mix.
pkg/skills/skillsvc/content_test.go New remote pull does not pollute ListBuilds case.
pkg/skills/skillsvc/install_oci_test.go New TestInstallFromOCI_DoesNotLeakIntoListBuilds.

Does this introduce a user-facing change?

Yes — the response of GET /api/v1beta/skills/builds changes.

  • Before: any skill pulled by install or by the content API appeared as a local build, because the endpoint listed every tag in the shared OCI store.
  • After: only skills produced by thv skill build (or equivalent POST /api/v1beta/skills/build) appear. Pulled skills stay on disk as a cache (transparent speed-up for re-previews / re-installs) but are no longer surfaced as builds.

Migration note

Unlike #4971, there is no automatic migration. Tags that exist in the OCI store from before this change lack the new annotation and therefore will not appear in ListBuilds until they are rebuilt with thv skill build. This is a deliberate trade-off: it eliminates the heuristic (looksLikePulledRef) that could misclassify an already-pulled fully-qualified tag as a "grandfathered build", at the cost of a one-time visibility gap for pre-feature local builds.

@samuv samuv requested a review from JAORMX as a code owner April 22, 2026 09:06
@samuv samuv self-assigned this Apr 22, 2026
@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label Apr 22, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 22, 2026

Codecov Report

❌ Patch coverage is 66.66667% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.99%. Comparing base (4e5301d) to head (0471ca5).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pkg/skills/skillsvc/local_build_marker.go 47.05% 6 Missing and 3 partials ⚠️
pkg/skills/skillsvc/build.go 64.28% 3 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5000      +/-   ##
==========================================
- Coverage   69.04%   68.99%   -0.06%     
==========================================
  Files         553      554       +1     
  Lines       73016    73056      +40     
==========================================
- Hits        50414    50404      -10     
- Misses      19601    19649      +48     
- Partials     3001     3003       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Collaborator

@JAORMX JAORMX left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, do we want to remove the local build annotation when doing push?

@samuv
Copy link
Copy Markdown
Contributor Author

samuv commented Apr 22, 2026

LGTM, do we want to remove the local build annotation when doing push?

I don't think we need that, If I understand correctly our Push path resolves by digest, which strips annotations

samuv added 3 commits April 22, 2026 11:21
Introduce a `dev.stacklok.toolhive.local-build` annotation that marks
tags in the shared local OCI store as produced by a local `Build`
rather than by an OCI pull. The marker lives on the descriptor entry
in `index.json`, not inside the manifest blob, so the artifact digest
is unchanged and nothing crosses the wire on push: oras-go resolves
the push reference by digest, which returns a plain descriptor and
strips descriptor annotations.

Adds `tagAsLocalBuild` (Tag + annotation) and `isLocalBuild` helpers.
Self-contained — no callers yet. Subsequent commits wire it into the
skill service so `ListBuilds` can filter out artifacts pulled into
the store by install or the content API only for caching.

Made-with: Cursor
Skills pulled into the shared OCI store by install or the content
API were tagged for caching and then surfaced by `ListBuilds`,
making every previewed or installed remote skill look like a local
build.

Wire the local-build descriptor annotation into the skill service:

- `Build` calls `tagAsLocalBuild` instead of `ociStore.Tag`, stamping
  the marker on the root-index descriptor entry in a single step.
- `ListBuilds` iterates `ociStore.ListTags` and filters by
  `isLocalBuild` before running the usual metadata enrichment, so
  pulled tags are skipped.
- `DeleteBuild` needs no change: removing the tag also drops its
  descriptor entry (and its annotation) from `index.json`.

OCI pulls (install and content) continue to tag the local store as
a cache, but `Registry.Pull` tags by digest — which resolves to a
plain descriptor in oras-go — so no annotation lands on the tag and
the cached artifact stays invisible to `ListBuilds`. Intent-documenting
comments at each pull site explain why the marker is deliberately
skipped there.

Made-with: Cursor
Add regression tests that simulate the real `Pull` tagging side
effect for both `GetContent` and `installFromOCI` and assert
`ListBuilds` stays empty afterwards. The invariant under test is
that `Registry.Pull` tags by digest — which yields a plain
descriptor — so the `dev.stacklok.toolhive.local-build` annotation
never lands on pulled tags and cached remote artifacts cannot
silently masquerade as local builds.

Made-with: Cursor
@samuv samuv force-pushed the skill-local-build-2 branch from 8749f22 to 0471ca5 Compare April 22, 2026 09:21
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Apr 22, 2026
@samuv samuv merged commit c3a640b into main Apr 22, 2026
41 of 42 checks passed
@samuv samuv deleted the skill-local-build-2 branch April 22, 2026 09:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants